iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0
Mobile Development

我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅系列 第 5

Day 5 - 導航不再迷路:go_router 實戰心得與架構演進

  • 分享至 

  • xImage
  •  

大家好,歡迎來到 Day 5!在昨天,我們為 Crew Up 建立了完整的多語言支援。今天,我們要來解決一個讓很多 Flutter 開發者頭痛的問題:導航管理

你是否曾經因為 App 的導航變得混亂而苦惱?使用者點擊返回鍵時,App 卻跳到了奇怪的頁面?或者想要支援深層連結,卻發現傳統的 Navigator 根本不夠用?

在 Flutter 的導航世界裡,從最初的 Navigator 1.0 到令人卻步的 Navigator 2.0,再到現在的 go_router,就像是從腳踏車演進到高鐵的過程。今天,我們要用 go_router 為 Crew Up 打造一個清晰、好維護的導航系統。

我們將重點探討:

  • go_router 的核心優勢:為什麼它在我們的專案中很好用
  • 清晰的路由架構設計:如何建立可維護的路由系統
  • 常數抽離與組織:從混亂到清晰的重構過程
  • 巢狀路由的實際應用:如何組織複雜的路由結構
  • 有主見的導航擴展:減少開發者心智負擔的設計
  • 強健的錯誤處理:自定義例外類型的實作

Flutter 導航系統演進史:從混亂到清晰

Flutter 導航系統的演進歷程

Navigator 1.0 (2017-2021)

  • 基於 Stack 的簡單導航
  • 命令式導航:Navigator.push(), Navigator.pop()
  • 優點:簡單直觀,學習門檻低
  • 缺點:難以處理複雜的導航場景,深層連結支援有限

Navigator 2.0 (2021-2022)

  • 宣告式導航:基於 RouterDelegate 和 RouteInformationParser
  • 完全控制導航堆疊
  • 優點:靈活性極高,支援複雜導航場景
  • 缺點:學習曲線陡峭,範本程式碼多

go_router (2022-至今)

  • 基於 Navigator 2.0 的封裝
  • 簡化複雜導航的實作
  • 優點:平衡了靈活性和易用性
  • 缺點:相對較新的解決方案

為什麼我們選擇 go_router?

在我們的「Crew Up!」專案中,我們選擇了 go_router 作為導航解決方案,實際使用後覺得以下優點:

🎯 解決了我們的真實痛點

  • 型別安全:編譯時就能發現路由錯誤,不用等到使用者抱怨才知道
  • 深層連結支援:想分享特定活動給朋友?一個 URL 就搞定
  • 簡潔的 API:少寫很多範本程式碼,專注在真正重要的功能上
  • 官方維護:不用擔心作者不維護,跟著 Flutter 一起進步
  • 學習曲線合理:比 Navigator 2.0 簡單太多,但功能不打折扣

我們的路由架構演進:從「能用」到「好用」

第一階段:常數大亂鬥 → 井然有序

說到路由管理,我們一開始也是新手,什麼都往 AppRouter 裡面塞。結果就是一個檔案裡面有一堆硬編碼的字串,修改路由時要找半天,還很容易拼錯字。

後來我們學乖了,把這些常數分門別類整理好。這就像是把原本散落一地的樂高積木,按照顏色和大小分類收納:

// lib/app/config/router/app_router.dart

// (imports omitted)

/// 路由路徑常數
class AppRoutePaths {
  AppRoutePaths._();

  // 主要路由
  static const String home = '/';
  static const String login = '/login';

  // 註冊流程路由
  static const String register = '/register';
  static const String registerIntroduction = '/register/introduction';
  static const String registerInterests = '/register/interests';
  static const String registerGoals = '/register/goals';

  // 活動相關路由
  static const String activityDetail = '/activity/:activityId';
  static const String activityList = '/activities';
  static const String activityListWithCategory = '/activities/:category';

  // 創建活動流程路由
  static const String createActivity = '/create-activity';
  static const String createActivityPreview = '/create-activity/preview';

  // 訊息相關路由
  static const String messageList = '/messages';
  static const String messageChat = '/messages/:chatId';
}

/// 路由名稱常數
class AppRouteNames {
  AppRouteNames._();

  // 主要路由名稱
  static const String home = 'home';
  static const String login = 'login';

  // 註冊流程路由名稱
  static const String register = 'register';
  static const String registerIntroduction = 'register_introduction';
  static const String registerInterests = 'register_interests';
  static const String registerGoals = 'register_goals';

  // 活動相關路由名稱
  static const String activityDetail = 'activity_detail';
  static const String activityList = 'activity_list';
  static const String activityListCategory = 'activity_list_category';
}

/// 路由參數名稱常數
class AppRouteParams {
  AppRouteParams._();

  static const String activityId = 'activityId';
  static const String category = 'category';
  static const String chatId = 'chatId';
}

🎯 常數抽離的優勢:

  • 更好的組織:路徑、名稱、參數分別管理
  • 減少錯誤:類型化的常數避免拼字錯誤
  • 易於維護:集中管理所有路由相關常數
  • 清晰的語意:使用 AppRoutePaths.home 比使用字串更清楚

第二階段:巢狀路由 → 讓 URL 說出故事

接下來我們發現,我們的路由其實有很清楚的家族關係。比如註冊流程就是 /register/register/introduction/register/interests 這樣的階層結構。

與其把它們當作平行的路由,不如讓它們真的成為「一家人」,用巢狀路由來表達這種關係:

// lib/app/config/router/app_router.dart

static GoRouter get router => GoRouter(
  initialLocation: AppRoutePaths.home,
  routes: [
    // 首頁路由
    GoRoute(
      path: AppRoutePaths.home,
      name: AppRouteNames.home,
      builder: (context, state) => const HomeScreen(),
    ),

    // 註冊流程路由(巢狀結構)
    GoRoute(
      path: AppRoutePaths.register,
      name: AppRouteNames.register,
      builder: (context, state) => const RegisterScreen(),
      routes: [
        // 註冊介紹頁面
        GoRoute(
          path: 'introduction',
          name: AppRouteNames.registerIntroduction,
          builder: (context, state) => const RegisterIntroductionScreen(),
        ),
        // 註冊興趣頁面
        GoRoute(
          path: 'interests',
          name: AppRouteNames.registerInterests,
          builder: (context, state) => const RegisterInterestsScreen(),
        ),
        // 註冊目標頁面
        GoRoute(
          path: 'goals',
          name: AppRouteNames.registerGoals,
          builder: (context, state) => const RegisterGoalsScreen(),
        ),
      ],
    ),

    // 活動相關路由(巢狀結構)
    GoRoute(
      path: AppRoutePaths.activityList,
      name: AppRouteNames.activityList,
      builder: (context, state) => const ActivityListScreen(),
      routes: [
        // 活動列表頁面路由(帶類別)
        GoRoute(
          path: ':category',
          name: AppRouteNames.activityListCategory,
          builder: (context, state) {
            final categoryString = extractRequiredParam(state, AppRouteParams.category);
            final category = ActivityCategoryExtension.fromString(categoryString);
            return ActivityListScreen(initialCategory: category);
          },
        ),
      ],
    ),

    // 訊息相關路由(巢狀結構)
    GoRoute(
      path: AppRoutePaths.messageList,
      name: AppRouteNames.messageList,
      builder: (context, state) => const MessageListScreen(),
      routes: [
        // 訊息聊天頁面
        GoRoute(
          path: ':chatId',
          name: AppRouteNames.messageChat,
          builder: (context, state) {
            final chatId = extractRequiredParam(state, AppRouteParams.chatId);
            return MessageChatScreen(chatId: chatId);
          },
        ),
      ],
    ),
  ],
);

💡 巢狀路由的優點:

  • 更清晰:路由配置的結構與 URL 的結構一致
  • 易於擴展:未來如果要為 /register 下的所有頁面添加統一的重導向會很方便
  • 為 ShellRoute 奠基:這是實現 ShellRoute(例如帶有底部導航欄的佈局)的必要前提

第三階段:錯誤處理 → 從「出錯就爆炸」到「優雅降級」

為錯誤找個家:專門的例外檔案

一開始我們的錯誤處理很粗糙,就是丟個 Exception 然後等著看會發生什麼事。後來發現這樣不行,使用者體驗很差,而且除錯也很困難。

所以我們決定為路由錯誤建立一個專門的檔案,讓錯誤處理變得更精確:

// lib/app/config/router/router_exceptions.dart

/// 路由相關的例外類型
/// 
/// 包含所有與路由導航相關的自定義例外,
/// 用於提供更精確的錯誤處理和除錯資訊。
library;

/// 路由參數缺失例外
/// 
/// 當必要的路由參數缺失或為空時拋出此例外。
/// 包含缺失的參數名稱,用於精確的錯誤處理。
class MissingParameterException implements Exception {
  /// 缺失的參數名稱
  final String parameterName;
  
  /// 建立新的參數缺失例外
  const MissingParameterException(this.parameterName);

  @override
  String toString() => 'Missing required route parameter: $parameterName';
  
  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is MissingParameterException &&
          parameterName == other.parameterName;

  @override
  int get hashCode => parameterName.hashCode;
}

簡化的參數提取方法

我們簡化了 extractRequiredParam 的簽名,讓它更簡潔:

// lib/app/config/router/app_router.dart

/// 安全地提取路由參數
static String extractRequiredParam(GoRouterState state, String paramName) {
  final value = state.pathParameters[paramName];
  if (value == null || value.isEmpty) {
    throw MissingParameterException(paramName);
  }
  return value;
}

型別安全的錯誤處理

在 errorBuilder 中,我們使用型別檢查而非字串比對:

// lib/app/config/router/app_router.dart

errorBuilder: (context, state) => ErrorHandlerService.buildErrorPage(
  context,
  state,
  onRetry: () {
    final error = state.error;
    if (error is MissingParameterException) {
      // 基於參數名稱決定適當的Fallback
      switch (error.parameterName) {
        case AppRouteParams.activityId:
          context.go(AppRoutePaths.home);
          break;
        case AppRouteParams.chatId:
          context.go(AppRoutePaths.messageList);
          break;
        case AppRouteParams.category:
          context.go(AppRoutePaths.activityList);
          break;
        default:
          context.go(AppRoutePaths.home);
      }
    } else {
      context.go(AppRoutePaths.home);
    }
  },
),

🚀 錯誤處理的改進:

  • 型別安全:不再依賴脆弱的字串比對
  • Fallback:根據不同的錯誤類型提供適當的Fallback
  • 易於維護:錯誤處理邏輯與錯誤訊息文字解耦

第四階段:有主見的導航 → 不再讓開發者選擇困難

太多選擇反而是負擔

我們之前犯了一個錯誤:覺得給開發者更多選擇是好事,所以幾乎每個路由都提供了 navigateTo... (go) 和 pushTo... (push) 兩種版本。

結果發現這樣反而造成困擾,每次導航時都要思考:「我該用 go 還是 push?」就像是餐廳菜單太多選項,反而不知道要點什麼。

後來我們決定變得「有主見」一點,根據業務邏輯直接決定該用哪種導航方式:

// lib/app/config/router/app_router.dart

/// 有主見的路由導航擴展
/// 為每個業務操作提供語意明確的導航方法
extension AppRouterNavigation on BuildContext {
  // --- 主要導航 (使用 go) ---
  /// 前往首頁
  /// 
  /// 使用 [go] 清空導航堆疊,直接導航到應用程式主頁
  void goHome() => go(AppRoutePaths.home);
  
  /// 前往登入頁面
  void goLogin() => go(AppRoutePaths.login);
  
  /// 前往訊息列表
  void goMessageList() => go(AppRoutePaths.messageList);
  
  /// 前往活動列表
  void goActivityList() => go(AppRoutePaths.activityList);

  // --- 註冊流程導航 (使用 push) ---
  /// 開始註冊流程
  void pushRegister() => push(AppRoutePaths.register);
  
  /// 進入註冊介紹頁面
  void pushRegisterIntroduction() => push(AppRoutePaths.registerIntroduction);
  
  /// 進入註冊興趣頁面
  void pushRegisterInterests() => push(AppRoutePaths.registerInterests);
  
  /// 進入註冊目標頁面
  void pushRegisterGoals() => push(AppRoutePaths.registerGoals);

  // --- 詳情頁面導航 (使用 push) ---
  /// 查看活動詳情
  /// 
  /// 推入活動詳情頁面,保留導航歷史讓使用者可以返回
  /// 
  /// [activityId] 要查看的活動 ID
  void pushActivityDetail(String activityId) {
    pushNamed(
      AppRouteNames.activityDetail,
      pathParameters: {AppRouteParams.activityId: activityId},
    );
  }

  /// 進入訊息聊天
  /// 
  /// [chatId] 要進入的聊天 ID
  void pushMessageChat(String chatId) {
    pushNamed(
      AppRouteNames.messageChat, 
      pathParameters: {AppRouteParams.chatId: chatId},
    );
  }

  // --- 創建活動流程 (使用 push) ---
  /// 開始創建活動
  void pushCreateActivity() => push(AppRoutePaths.createActivity);
  
  /// 預覽創建的活動
  void pushCreateActivityPreview() => push(AppRoutePaths.createActivityPreview);

  // --- 特殊導航 ---
  /// 瀏覽特定分類的活動
  void pushActivityListWithCategory(ActivityCategory category) {
    pushNamed(
      AppRouteNames.activityListCategory,
      pathParameters: {AppRouteParams.category: category.name},
    );
  }
}

🎯 有主見的設計原則:

  • 詳情頁使用 push:99% 的情況下使用者都需要返回
  • 主分頁使用 go:切換時清空堆疊,避免導航混亂
  • 流程使用 push:註冊、創建流程都是一系列的 push 操作
  • 語意明確:方法名稱直接表達業務意圖

實作案例:ID 參數導航的最佳實踐

從物件傳遞到 ID 導航的改進

在我們專案中,活動詳情的導航方式經歷了重要的改進。我們從傳遞整個 Activity 物件改為傳遞 activityId

// lib/features/home/presentation/screens/index_screen.dart

// (imports omitted)

class IndexScreen extends ConsumerStatefulWidget {
  // 改進後的導航方式
  void _onActivityTap(Activity activity) {
    // 使用 ID 導航,而不是傳遞整個物件
    context.pushActivityDetail(activity.id);
  }

  void _onPopularActivityTap(Activity activity) {
    context.pushActivityDetail(activity.id);
  }

  void _onViewMoreTap() {
    context.goActivityList();
  }

  void _onThemeTap(ActivityCategory category) {
    context.pushActivityListWithCategory(category);
  }
}

ActivityDetailScreen 的對應改進

活動詳情頁面現在接收 activityId 而非整個物件:

// lib/features/activity/presentation/screens/activity_detail_screen.dart

// (imports omitted)

/// 活動詳情頁面 - 使用路由參數版本
class ActivityDetailScreen extends ConsumerWidget {
  final String activityId;

  const ActivityDetailScreen({super.key, required this.activityId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 根據 ID 從狀態管理中取得活動資料
    final activityAsync = ref.watch(activityByIdProvider(activityId));

    return Scaffold(
      backgroundColor: const Color(0xFFfff8eb),
      appBar: _buildAppBar(context),
      body: activityAsync.when(
        data: (activity) => _buildContent(context, ref, activity),
        loading: () => const Center(
          child: CircularProgressIndicator(color: AppColors.actionPrimary),
        ),
        error: (error, stackTrace) => _buildErrorContent(context),
      ),
    );
  }
}

✅ ID 導航的優勢:

  • 深層連結支援:使用者可以直接訪問特定活動
  • 狀態管理最佳化:可以根據 ID 進行資料快取
  • 除錯便利性:URL 直接顯示當前狀態
  • 分享功能:使用者可以分享特定活動的連結

go 和 push 的使用時機:不再困惑

相信很多人都有這個疑問:什麼時候用 go?什麼時候用 push 這個問題困擾了我們很久,直到我們建立了「有主見的導航擴展」,才算是徹底解決。

實際使用場景指導

✅ 適合用 go 的情況:

  1. 主要頁面切換:底部導航欄切換時
  2. 登入後跳轉:登入成功後直接到首頁
  3. 錯誤頁面返回:404 頁面返回首頁
// 實際專案中的範例
void onLoginSuccess() {
  // 登入成功後清空導航堆疊,直接到首頁
  context.goHome();
}

void onBottomNavTap(int index) {
  // 底部導航切換,不需要保留前一頁
  switch (index) {
    case 0: context.goHome(); break;
    case 1: context.goActivityList(); break;
  }
}

✅ 適合用 push 的情況:

  1. 詳情頁面:從列表進入詳情
  2. 表單頁面:註冊、創建流程
  3. 需要返回的頁面:保留導航上下文
// 實際專案中的範例
void onActivityTap(Activity activity) {
  // 查看活動詳情,使用者可能要返回列表
  context.pushActivityDetail(activity.id);
}

void onStartRegistration() {
  // 開始註冊流程
  context.pushRegister();
}

實際使用心得:那些「踩過的坑」和「意外的收穫」

經過幾個月的實際使用,我們對 go_router 和這套導航系統有了更深的體會:

🎯 那些真的有用的東西:

  • 型別安全救了我們很多次:以前改個路由名稱,要到處找哪裡用到,現在編譯器會直接告訴你
  • 常數化路由是維護神器:改路由路徑只要改一個地方,再也不用擔心漏掉哪個角落
  • 巢狀路由讓專案結構一目了然:看 URL 就知道頁面的層級關係
  • 有主見的設計真的減少腦力消耗:不用每次都糾結要用 go 還是 push
  • 錯誤處理變得更人性化:使用者不會再看到莫名其妙的錯誤頁面

總結:從路由混亂到導航井然

今天我們為 Crew Up 打造了一個從基礎到完整的導航系統。從一開始的路由常數大亂鬥,到最後的有主見導航擴展,每一步都是為了解決實際開發中遇到的問題。

💡 這次經驗教會我們什麼?

  1. 好的架構是慢慢長出來的:我們的路由系統不是一天建成的,而是在開發過程中逐步優化的結果

  2. 有時候「有主見」是好事:與其讓開發者每次都做選擇,不如根據業務邏輯直接做出最合適的決定

  3. 重構是值得的投資:雖然重構常數分類看起來很瑣碎,但長期來看絕對值得

  4. 錯誤處理要考慮使用者體驗:技術上的錯誤不應該直接暴露給使用者

  5. 文件註解是未來的自己會感謝你的事:幾個月後回來看程式碼,你會感謝當初寫註解的自己

🚀 實際收穫

現在我們有了一個清晰、好維護的導航系統。新加入團隊的開發者可以很快理解路由結構,使用者也能享受流暢的導航體驗。如果你在實作過程中遇到困難,別擔心,這很正常。重要的是一步一步來,慢慢改進。

有了這個穩固的導航基礎,我們可以專心處理更複雜的功能。明天,我們要來面對另一個經典難題:狀態管理。Day 6 見!


📋 相關資源

📝 專案資訊

  • 專案名稱: Crew Up!
  • 開發日誌: Day 5 - go_router 實戰:從基礎路由到完整導航解決方案
  • 文章日期: 2025-09-19
  • 技術棧: Flutter 3.8+, Dart 3.8+, go_router

上一篇
Day 4 - 國際化與在地化:打造全球化的 App
下一篇
Day 6 - Riverpod 2.0 實戰攻略:從架構設計到效能優化的完整指南
系列文
我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言